Interaktive Visualisierung der Wutbürger-Tweets mit Bokeh, 19.12.21¶
Grundlage ist das Datenset #wutbuerger.json gescrappt über
Twitter-API Academic Research; endpoint search all
Twarc2
Grundlage dazu weitere Test-Notebooks
Optimierungen im Layout sind ggf. noch möglich / nötig
Farben der Marker
Ticks der Spines
Hintergrundfarbe
Farben und Styling der Hoverox
u.a.
import pandas as pd
import numpy as np
import pytz
from bokeh.plotting import figure, output_file, output_notebook, show
from bokeh.models import ColumnDataSource, CDSView, Legend
from bokeh.models import CustomJS, Slider, OpenURL, TapTool, CustomJSFilter
from bokeh.models import DatetimeTickFormatter
from bokeh.models.tools import HoverTool, BoxZoomTool, ResetTool, PanTool
from bokeh.layouts import column, row
from bokeh.io import show
output_notebook()
# read data
df = pd.read_csv('../data/211219-wutbürger-preprocessed.csv',
parse_dates=['date'])
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 9117 entries, 0 to 9116
Data columns (total 11 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 id 9117 non-null int64
1 date 9117 non-null datetime64[ns, UTC]
2 tweet 9117 non-null object
3 hashtags 9117 non-null object
4 username 9117 non-null object
5 link 9117 non-null object
6 nretweets 9117 non-null int64
7 nlikes 9117 non-null int64
8 nreplies 9117 non-null int64
9 nqoutes 0 non-null float64
10 char_per_url_free_tweet 9117 non-null int64
dtypes: datetime64[ns, UTC](1), float64(1), int64(5), object(4)
memory usage: 783.6+ KB
# data wrangling and create new columns
# hours_minutes stellt die x-Achse dar
df.loc[:, 'hours_minutes'] = ((df.loc[:, 'date'].dt.hour * 60) + df.loc[:, 'date'].dt.minute) / 60
# tweet_score zeigt die Größe der Marker an
df.loc[:, 'tweet_score'] = 5 + ((df.loc[:, 'nlikes']) + (df.loc[:, 'nretweets'] * 2) + (df.loc[:, 'nreplies'] * 3)) / 100
# convert time zone: funktioniert nicht wie erwartet > Berlin is UTC + 1 (Summertime +2)
# alle Datumsangaben sind (bei der V1-Visu) 2 Stunden früher > 2 Stunden dazu addiert
df.loc[:, 'date'] = df.loc[:, 'date'].dt.tz_convert(pytz.timezone('Europe/Berlin'))
df.loc[:, 'date'] = df.loc[:, 'date'] + pd.Timedelta(hours=2)
df = df.drop(['nqoutes'], axis=1)
# Die Funktion setzt in jeden Tweets an jeder 8. Indexstelle den html-Tag <br>
# Damit wird bei der Hoverbox ein Zeilenumbruch erzeugt, um den Text lesbar zu machen.
def insert_br(text):
'''
function inserts <br> every 8th word
to create custom tooltip with html
'''
text = text.split(' ')
for i in range(len(text) // 8):
text.insert((i+1) * 8, '<br>')
return ' '.join(text)
# creat new column
df.loc[:, 'tweet_br'] = df.loc[:, 'tweet'].apply(lambda x: insert_br(x))
# create diskurse
# Hier werden die Diskurse erstellt mittels Tuples von 3 Elementen erstellt:
# Index 0: Label des Diskurses
# Index 1: Farbe des Diskurses
# Index 2: Liste von Schlüsselworten, die zu dem Diskurs gehören
s21_diskurs = ('S21', 'red', ['s21', 'stuttgart', 'stuttgart21', 'stuttgarter', 'brandschutz'])
afd_diskurs = ('AfD / Pegida', 'green', ['afd', 'pegida', 'noafd', 'nopegida', 'nazis', 'nazi', 'nonazis', 'lügenpresse', 'fckafd'])
corona_diskurs = ('Corona', 'orange', ['coronaleugner', 'covidioten', 'covidiot', 'corona', 'covid19', 'coronavirus', 'coronademo', 'infektion', 'maske', 'maskenverweigerer' \
'cov19unvereinbar', 'lockdown', 'impfgegner', 'maskenpflicht', 'drosten', 'pandemie', 'virus', 'coronakrise'])
mutbürger_diskurs = ('Mutbürger', 'darkturquoise', ['mutbürger'])
hutbürger_diskurs = ('Hutbürger', 'greenyellow', ['hutbürger'])
querdenker_diskurs = ('Querdenker', 'hotpink', ['querdenker', 'querdenken', 'verschwörungstheoretiker']) # verschwörungstheoretiker ist vllt. eigener Diskurs
diskurs_liste = [s21_diskurs, afd_diskurs, corona_diskurs, mutbürger_diskurs, hutbürger_diskurs, querdenker_diskurs, ('keine Zuordnung', '#1da1f2') ]
# color_list = ['red', 'green', 'orange', '#1da1f2']
# https://docs.bokeh.org/en/latest/docs/reference/colors.html
# Die Funktion erstellt eine Spalte für den jeweiligen Diskurs
# Die Liste der Hashtags wird in der List-Comprehension geprüft, ob darin Schlüsselwörter des entsprechenden Diskurses enhalten ist
# Wenn das so ist, wird in der Spalte für den Diskurs das Label des Diskurses eingetragen, falls nicht wird np.nan eingetragen
# Vorteil von jeweils einer eigenen Spalte pro Diskurs: Überschneidungen von Diskurses können visuell erfasst werden
def diskurs_maker(hashtaglist, diskurstuple):
'''
checks if hashtaglist contains hastag that ist in list of Diskurs.
'''
if any(item in hashtaglist for item in diskurstuple[2]):
return diskurstuple[0]
else:
return np.nan
# Die for-Schleife erstellt die Spalten mit den Diskursen
for diskurs in diskurs_liste[:-1]:
df.loc[:, diskurs[0]] = df.loc[:, 'hashtags'].apply(lambda x: diskurs_maker(x, diskurs))
# Die Lambda-Funkton prüft, ob es so viele NaNs in einer Reihe wie Diskurse gibt,
# also prüft, ob ein Tweet zu keinem der Diskurse gepasst hat:
# dann wird keine Zuordnung gesagt
# Die Zahl der Diskurse wird aus mit len(Diskursliste - 1) errechnet
# Das letzte Element der Diskurslist ist das Tuple für die Farbzuordnung der nicht zugeordneten Tweets
df.loc[:, 'keine Zuordnung'] = df.isna().sum(axis=1).apply(lambda x: 'keine Zuordnung' if x == len(diskurs_liste) - 1 else np.nan )
# Die for-Schleife erstellt auf Basis der Datenspalte die sources für die Bokeh-Figure
# Dazu wird für jede Diskursspalte mit einer Boolschen Maske geprüft, ob das Diskurs-Label in der Spalte enthalten ist
# Dann wird mit ColumnDataSource der gefilterte Dataframe in source umgewandelt und der source_list angefügt.
source_list = []
for diskurs in diskurs_liste:
source = df.loc[df.loc[:, diskurs[0]] == diskurs[0], :]
source_list.append(ColumnDataSource(source))
# create sliders
# In dieser Zellen werden die Slider für Retweets, Likes, Replies und Textlänger erstellt
# Wichtig ist, das der javascript Code für jeden Slider, jede source für die Diskurse mit
# sourceX.change.emit() aktiviert, sonst passiert bei den Slidern nichts.
# Ebenso muss bei den Args alle Sources übergeben werden.
# Die init_values sind die min bzw. max Werte der jeweiligen Datenspalte;
# diese werden für die 'Begrenzung' des Slider-Raumes genutzt und für den Start-Value des Sliders genutzt
# >>> javascript code ist nötig, damit die erstellte html-Datei auch als Standalone funktioniert!
# javascript code
js_code = """
source0.change.emit();
source1.change.emit();
source2.change.emit();
source3.change.emit();
source4.change.emit();
source5.change.emit();
source6.change.emit();
"""
# source dict
source_dict = dict(source0=source_list[0],
source1=source_list[1],
source2=source_list[2],
source3=source_list[3],
source4=source_list[4],
source5=source_list[5],
source6=source_list[6])
# Refactor > create for-loop
# Slider für Anzeige der Retweets
init_value_rt = (df.loc[:, 'nretweets'].min(), df.loc[:,'nretweets'].max())
rt_slider = Slider(start=init_value_rt[0], value=0, end=100, step=1, title='Anzahl der Retweets (zw. 0 und 100 einstellbar)')
rt_slider.js_on_change('value', CustomJS(args=source_dict, code=js_code))
# Slider für Anzeige der Likes
init_value_li = (df.loc[:, 'nlikes'].min(), df.loc[:,'nlikes'].max())
li_slider = Slider(start=init_value_li[0], value=0, end=100, step=1, title='Anzahl der Likes (zw. 0 und 100 einstellbar)')
li_slider.js_on_change('value', CustomJS(args=source_dict, code=js_code))
# Slider für Anzeige der Replies
init_value_li = (df.loc[:, 'nreplies'].min(), df.loc[:,'nreplies'].max())
re_slider = Slider(start=init_value_li[0], value=0, end=100, step=1, title='Anzahl der Antworten (zw. 0 und 100 einstellbar)')
re_slider.js_on_change('value', CustomJS(args=source_dict, code=js_code))
# Slider für Anzeige der Tweetlänge
init_value_tl = (df.loc[:, 'char_per_url_free_tweet'].min(), df.loc[:,'char_per_url_free_tweet'].max())
tl_slider = Slider(start=init_value_tl[0], value=0, end=init_value_tl[1], step=1, title='Länge der Tweets')
tl_slider.js_on_change('value', CustomJS(args=source_dict, code=js_code))
# TODO: Try RangeSlider for Tweetlänge
# create figure, custom_filters and views
# Zunächst wird die figure mit den Maßen, Größenanpassung und Toolbar instantiiert.
p = figure(height=875,
width=875,
sizing_mode="stretch_both", # vergrößert die figure auf die Breite des Browsers
toolbar_location="above",
tools= ['pan', 'wheel_zoom', 'box_zoom', 'save', 'reset', 'tap'])
view_list = []
custom_filter_list = []
# für jede source wird ein Customfilter angelegt
# im javascript code werden alle Slider-Einstellungen verarbeitet
# nur die Indices, die zu den Slider-Einstellungen passen, werden zurückgegeben
# der Customfilter wird der custom_filter_list beigefügt
for source in source_list:
custom_filter = CustomJSFilter(args=dict(rt_slider=rt_slider, li_slider=li_slider, re_slider=re_slider, tl_slider=tl_slider), code='''
var indices = [];
for (var i = 0; i < source.get_length(); i++){
if (source.data['nretweets'][i] >= rt_slider.value && source.data['nlikes'][i] >= li_slider.value && source.data['nreplies'][i] >= re_slider.value && source.data['char_per_url_free_tweet'][i] >= tl_slider.value){
indices.push(true);
} else {
indices.push(false);}}
return indices;
''')
custom_filter_list.append(custom_filter)
# für jeden Source wird eine View erstellt
# bei den filters wird die gesamte custom_filter_list übergeben
for source in source_list:
view = CDSView(source=source, filters=custom_filter_list)
view_list.append(view)
# aus den Listen werden die Grafiken erstellt und in eine figure gepackt
for source, diskurs, view in zip(source_list, diskurs_liste, view_list):
p.circle(x='hours_minutes', y='date', color=diskurs[1], fill_alpha=0.5, size='tweet_score', legend_label=diskurs[0], source=source, view=view)
# Taptool: Durch einen click auf den Marker wird der Link zum Tweet aus der link-Spalte abgerufen:
# In einem neuen Browser Fenster öffnet sich der Tweet
taptool = p.select(type=TapTool)
taptool.callback = OpenURL(url='@link')
# add hover
hover = HoverTool(tooltips=[('Datum', '@date{%F %H:%M}'),
('Tweet', '<span style="font-size: 17px; font-weight: bold; width=42">@tweet_br{safe}</span>'),
('von', '@username'),
('Retweets', '@nretweets'),
('Likes', '@nlikes'),
('Replies', '@nreplies'),
('Tweet-Score', '@tweet_score'),
],
formatters={'@date': 'datetime'})
p.add_tools(hover)
# Layout styling der figure
# interactive legend outside the plot and other layout featurs
p.y_range.flipped = True
p.yaxis.formatter=DatetimeTickFormatter()
p.legend.location = 'top_left'
p.legend.click_policy='hide'
p.legend.title = 'Diskurse\n (click to hide)'
p.legend.title_text_font_style = 'normal'
p.title.text = 'Wutbürger Tweets'
p.xaxis.axis_label = 'Uhrzeit'
p.yaxis.axis_label = 'Datum'
p.add_layout(p.legend[0], 'left')
# change just some things about the x-grid
p.xgrid.grid_line_color = None
# change just some things about the y-grid
p.ygrid.band_fill_alpha = 0.1
p.ygrid.band_fill_color = "grey"
# output to standalone HTML file
# output_file('211219-Wutbüger_interaktiv.html')
# Anordnung des Layouts von Slidern und figure
layout = row(column(rt_slider, li_slider, re_slider, tl_slider), p)
show(layout)